PROFDINFO.COM

Votre enseignant d'informatique en ligne

Les délégués

Le principe de la délégation est à la base très simple: un délégué permet de passer une méthode à une autre méthode. Ainsi, la deuxième peut faire appel à la première pour remplir une partie de ses fonctions, permettant de faire varier ces fonctionnalités d'un appel à l'autre.

Il est impossible de passer une méthode à une autre méthode directement - en effet, comment déclarerait-on le paramètre qui recevra cette méthode? Comme ça?

public int MaMéthode(méthode maSousMéthode, int nombre)
{
}

Cette façon de faire ne fonctionne pas, mais ne serait-ce pas fort utile si ça pouvait marcher comme ça? On passe un nombre et une méthode en paramètre, et MaMéthode fait appel à maSousMéthode pour faire une partie de son traitement sur nombre. D'un appel à l'autre, on passe une fonction différente à MaMéthode et le traitement peut varier.

Les délégués permettent exactement ceci, et la syntaxe n'est pas très différente! En fait, la déclaration de MaMéthode peut fonctionner telle quelle, à condition qu'on ait déjà déclaré "méthode" comme un délégué.

Un délégué a un nom, une valeur de retour et une liste de paramètres typés. On peut le voir comme une signature de méthode. Par exemple, si je déclare ceci:

public delegate bool méthode(int param);

J'ai défini le délégué "méthode". Ce délégué représente une fonction qui reçoit un int en paramètre et retourne un bool. Notez qu'un délégué n'a pas d'implémentation! Pour garder ça simple, on peut voir le délégué comme ne faisant que représenter un "type" de fonction, un peu comme int représente un type de données.

Quand je déclare ensuite MaMéthode tel qu'on l'a fait ci-haut, j'utilise mon délégué "méthode" comme type de fonction, indiquant que MaMéthode reçoit en paramètre une fonction de type "méthode" (c'est à dire une fonction acceptant un int et retournant un bool). Toute fonction correspondant à ce type pourra être passée à MaMéthode. Dans le corps de MaMéthode, quand je voudrai faire référence à la fonction (l'appeler), j'utiliserai le nom du paramètre "maSousMéthode", exactement comme quand j'utilise "nombre" pour faire référence au deuxième paramètre qui m'a été passé (paramètre qui peut être n'importe quelle valeur de type int).

On peut utiliser des délégués n'importe où et n'importe quand, mais ils sont certainement fort utiles dans les déclarations de classes génériques. En effet, une classe générique qui manipule des objets de type T ne peut pas faire grand chose avec ces objets. Comme ils sont des T, impossible d'accéder à leurs membres, de les comparer, de les trier ou d'en modifier le contenu.

Il est toutefois possible de prévoir un traitement générique sur des objets T, traitement qui sera concrétisé par une méthode reçue en paramètre. Dans la classe générique, on déclarera un délégué approprié et une méthode ayant une instance de ce délégué parmi ces paramètres. La méthode appellera l'instance du délégué au moment approprié.

Lorsqu'on utilisera la classe générique (lorsqu'on en fera une instance, précisant ce que sera T), on pourra utiliser la méthode générique en lui passant une fonction à la signature appropriée (cette fonction se trouvera généralement dans la classe qui précise T).

Un exemple concret tout simple: la Paire

Illustrons le tout avec un exemple simple. Supposons une classe Paire<T> qui me permet de conserver 2 objets (ou valeurs!) quelconques et de les traiter comme une paire. La classe pourrait ressembler à quelque chose comme:

class Paire<T>
{
   public T[] objets;
   
   public Paire(T objet1, T objet2)
   {
      objets = new T[2];
      objets[0] = objet1;
      objets[1] = objet2;
   }

   public Paire()
   {
      objets = new T[2];
   }
 
   public override string  ToString()
   {
      return objets[0].ToString() + "; " + objets[1].ToString();
   }
}

La Paire<T> contient donc un tableau de T comme seul attribut. Un constructeur instancie ce tableau, lui donnant 2 cases et y plaçant deux items de type T reçus en paramètres. Un autre constructeur (nul) instancie (et dimensionne) le tableau mais le laisse vide. Pour plus de simplicité, tout est laissé public.

Une Paire peut également être convertie en string grâce à un simple override de ToString() qui utilise le ToString de chacun des items de la Paire et place un point-virgule entre les deux. On peut faire appel à objets[x].ToString() puisque tout objet ou valeur aura une implémentation de ToString, donc ça tient peu importe ce que sera T.

Par contre, supposons que je veuille trier mes 2 objets. Je ne peux absolument rien faire pour ça dans Pile<T> puisque je ne sais même pas ce que je vais recevoir. Trier des strings est différent de trier des int, et différent encore de trier des Méchants ou des Assiettes. En fait, il est même possible de trier des Assiettes de plusieurs façons différentes (par couleur ou par épaisseur, sans oublier ascendant ou descendant)...

C'est là qu'il est fort utile de créer un délégué. Comme d'habitude, tout ce qui concerne la mécanique de ma collection doit se trouver dans la collection. Tout ce qui concerne le contenu doit se trouver ailleurs, normalement dans la classe du contenu.

Je peux très bien implémenter une méthode de tri qui s'occupe d'inverser les deux cases du tableau si elles ne sont pas triées et qui les laisse telles quelles si elles le sont. Tout ce dont j'ai besoin pour faire ça, c'est d'une méthode qui me permet de savoir si elles sont triées ou pas. Je peux décider que cette méthode doit accepter deux items quelconques et retourner un entier. Si l'entier est négatif, le premier item va avant le deuxième. Si l'entier est positif, le deuxième item va avant le premier. Si l'entier est nul, les deux items sont équivalents. (Je m'inspire ici de la méthode String.Compare, un grand classique dans le monde de la comparaison).

Je vais donc déclarer un délégué qui correspond à cette signature:

 public delegate int comparaison(T item1, T item2);

Ce faisant, je viens de déclarer un nouveau "type de fonctions": les fonctions de type "comparaison", qui reçoivent deux items génériques et qui retournent un entier.

Je peux ensuite implémenter ma méthode de tri en lui faisant accepter une telle fonction et en l'utilisant comme si elle existait:

public void Trier(comparaison laMéthode)    
{    
   T temp;    

   if (laMéthode(objets[0], objets[1]) > 0)    
   {    
       temp = objets[0];  
       objets[0] = objets[1];   
       objets[1] = temp;   
    }    
}

Trier étant générique, elle est très simple. Elle appelle laMéthode, la fonction reçue en paramètre, en lui passant les deux cases du tableau. Si le résultat est positif, elle inverse les deux cases. Voilà, notre Paire est triée!

On sait que laMéthode acceptera nos deux objets puisque si elle a été reçue en paramètre (et que le code compile!) elle devra correspondre à notre délégué "comparaison" et acceptera deux T. On sait également qu'elle retournera un entier. Évidemment on ne peut pas s'assurer que laMéthode fera le travail qu'on suppose, mais on peut l'exiger pour que ça fonctionne et laisser les utilisateurs de la Paire s'arranger!

Testons le tout - le retour des Assiettes

Pourquoi ne pas décider de faire des paires d'Assiettes? C'était tellement amusant de les empiler... On réutilisera la classe Assiette du module précédent, mais en y ajoutant une fonction de comparaison:

class Assiette
{
   public string couleur;
   public double épaisseur;

   public Assiette(string couleur, double épaisseur)
   {
      this.couleur = couleur;
      this.épaisseur = épaisseur;
   }
   
   public override string ToString()
   {
      return couleur + "(" + épaisseur + ")";
   }

   public static int Compare(Assiette a1, Assiette a2)
   {
      if (a1.épaisseur < a2.épaisseur)
         return -1;
      else if (a1.épaisseur > a2.épaisseur)
         return 1;
      else
         return 0;
   }
}

N'oublions pas de lui fournir une implémentation de ToString() (sachant que la Paire va l'utiliser - et de toute façon c'est toujours une bonne idée de le faire pour toute classe).

Notez la méthode Compare: elle accepte deux Assiettes et retourne un int; elle fonctionne comme String.Compare et compare en fait les épaisseurs des assiettes. En effet, il existe plus d'une façon de comparer des assiettes entre elles, il nous suffit d'en choisir une qui est fonctionnelle dans notre design.

Remarquez que, comme String.Compare, elle est statique, c'est-à-dire qu'on l'appellera à partir d'Assiette et non pas à partir d'une instance - un autre choix de design, j'aurais pu faire un équivalent à CompareTo du string mais selon moi si on n'utilise pas l'objet appelant dans la comparaison, mieux vaut laisser ça statique. Et pourquoi donc ne pourrait-on pas utiliser l'objet appelant dans la comparaison? Pensez-y!

J'ai donc une classe Assiette qui sait comment comparer deux Assiettes et déterminer laquelle va avant l'autre, ainsi qu'une Paire générique qui s'occupe de la mécanique de tri pour autant qu'on lui dise comme déterminer quel item va avant l'autre. Je suis donc fin près à trier des paires d'assiettes! Youppi!

static void Main(string[] args)
{
   Assiette a1 = new Assiette("bleue", 2);
   Assiette a2 = new Assiette("rouge", 1);
   Paire<Assiette> paire = new Paire<Assiette>(a1, a2);
   Console.WriteLine(paire);
 
   paire.Trier(Assiette.Compare);
   Console.WriteLine("\n" + paire);
}

Je crée donc deux assiettes: une bleue de 2 (disons) cm d'épaisseur et une rouge d'un centimètre. Je crée ensuite une nouvelle paire d'assiettes en lui passant mes deux assiettes. J'affiche la paire à l'écran (grâce à ToString() qui est invoqué automatiquement puisque WriteLine s'attend à un string).

Je demande ensuite à ma paire de se trier en lui passant la méthode Assiette.Compare pour lui permettre de déterminer laquelle va avant l'autre. J'affiche de nouveau ma paire et bingo, elle est triée en ordre d'épaisseur.

Encore une fois, c'est la classe générique qui s'occupe de la mécanique - l'assiette ne sait pas qu'elle est dans une paire ou même dans un tableau. Toutefois c'est l'objet concret qui s'occupe du contenu: la paire ne sait pas comment comparer des assiettes. Voilà un découpage élégant et efficace.

Évidemment, vous me direz sans doute qu'on aurait pu trier les assiettes par couleur. Pourquoi pas? En fait, ce ne serait pas du tout une mauvaise idée de fournir la logique pour les deux façons et de laisser l'usager décider ce qu'il veut utiliser.

Ajoutons donc à notre assiette le code suivant:

public static int CompareCouleur(Assiette a1, Assiette a2)
{
   return a1.couleur.CompareTo(a2.couleur);
}

Une autre méthode statique à la même signature (essentiel!) mais qui retourne cette fois-ci le résultat de la comparaison des chaînes "couleur". N'est-ce pas fort élégant ça aussi?

On peut ensuite ajouter à notre programme de test:

 paire.Trier(Assiette.CompareCouleur);
 Console.WriteLine("\n" + paire + "\n");

On appelle la méthode paire.Trier mais ce coup-ci on lui passe la fonction CompareCouleur. Magie: notre paire est triée en ordre alphabétique de couleur.

Trouver une assiette dans la paire

Examinons un dernier exemple de délégué qui, celui-ci, n'utilisera pas de méthode statique. Supposons que l'on veuille parcourir notre paire (pas très long, mais bon, c'est un exemple simple, je vous l'avais dit dès le début) à la recherche d'une assiette qui corresponde à une autre. Autrement dit, je veux chercher une assiette dans ma paire et la recevoir si je la trouve (disons que je recevrai null si elle n'existe pas).

Encore une fois, la mécanique de recherche doit se trouver dans la paire: c'est elle qui s'occupera de parcourir ses éléments internes et qui retournera celui qu'elle trouvera. Par contre, ce qui concerne le contenu sera dans l'assiette: c'est là qu'on définira comment comparer deux assiettes afin de savoir si elles sont pareilles. La paire ne peut pas savoir comment faire ça (sinon elle n'est plus générique).

J'ajouterai donc à ma paire un délégué et une fonction de recherche utilisant le délégué:

public delegate bool prédicat(T item);
      
public T Trouver(prédicat trouve)    
{       
   foreach (T item in objets)          
      if (trouve(item))
         return item;     
   return default(T);    
}

Mon délégué s'appelle "prédicat" (un prédicat est une affirmation quelconque qui peut être vraie ou fausse; on utilise généralement des prédicats pour vérifier si un objet possède une propriété ou une caractéristique quelconque, pour comparer des objets ou pour vérifier la relation entre deux objets). Il accepte un item de type T et retourne un booléen - vrai si l'item en paramètre est pareil à l'item à comparer, faux sinon.

Ma fonction Trouver retourne un T (l'item trouvé) et déclare "trouve", une méthode de type "prédicat". Elle parcourt le tableau et si "trouve" dit que l'item qu'on vient de lui passer est bon, elle le retourne. Si elle sort du foreach sans avoir rien trouvé, elle retourne le T par défaut (nul dans le cas des Assiettes).

La mécanique est toute simple. Il nous reste à nous occuper du ce qui concerne le contenu, en ajoutant ceci à Assiette:

public bool assiettePareille(Assiette autreAssiette)
{
   if (épaisseur == autreAssiette.épaisseur && 
       couleur.CompareTo(autreAssiette.couleur) == 0)
      return true;
   else
      return false;
}

La méthode assiettePareille reçoit une "autreAssiette" en paramètre et retourne vrai si cette autre assiette est pareille à nous et faux sinon. Pour qu'une assiette soit pareille, elle doit avoir la même épaisseur et la même couleur.

N'oublions pas que cette méthode n'est pas statique, donc on demande à une assiette (l'objet appelant) de se comparer à une autre (le paramètre) et de nous dire si elle est pareille à celle-ci.

On aurait pu faire une version statique de cette fonction, mais cette fois-ci je trouve que c'est plus joli de demander à une assiette de se comparer à une autre que de demander à la classe si deux assiettes sont pareilles. En plus on pourra aisément utiliser assiettePareille en version instance (contrairement à Compare tout à l'heure) donc pourquoi pas.

Notre programme de test peut donc être modifié en lui ajoutant ceci:

Assiette a3 = new Assiette("rouge", 1);
Assiette a4 = paire.Trouver(a3.assiettePareille);
if (a4 != null)
   Console.WriteLine(a4);
else
   Console.WriteLine("Pas d'assiette " + a3 + " dans la paire!");

On crée une troisième assiette et on voudra chercher dans la paire à la recherche d'une assiette pareille à cette dernière. a4 sera un pointeur vers l'assiette pareille à a3 dans la pile. On demande à la paire de trouver une assiette pour laquelle la fonction "a3.assiettePareille" retourne True, autrement dit une assiette pareille à a3!

N'oublions pas qu'a4 peut être nul si paire n'a pas trouvé d'équivalent.

Notre programme de test, lorsque complet, génère donc la sortie suivante:

bleue(2); rouge(1)

rouge(1); bleue(2)

bleue(2); rouge(1)

rouge(1)